feat(pullsync): delivery batching performance analysis - #5540
Conversation
|
One comment regarding TCP (and UDP) Dynamics, a standard internet packet (MTU) is about 1500 bytes. A single 4 KB chunk already requires 3 TCP packets. Whether you flush chunks to the OS individually or in a batch, the OS TCP stack will still stream them out in 1500-byte packets at the maximum rate the network allows. |
|
Until the real cluster measurements are performed, I have run a benchmarks on local machine, but using The benchmark that is executed is: go test -bench=BenchmarkDeliveryLibP2P/n=120 -benchmem -benchtime=5x ./pkg/pullsyncResults without added latency: Than a latency is set with: sudo dnctl pipe 1 config delay 200ms
echo "dummynet out from 127.0.0.1 to 127.0.0.1 pipe 1" | sudo pfctl -f - -eAnd the benchmark is run again, the results are: If you want to reproduce the benchmarks on the local machine, do not forget to remove the latency: sudo pfctl -F all -d
sudo dnctl -q flushThe results show that the latency has an impact on the performance of the pullsync algorithm, between two runs (with and without latency), but there are no differences between the batched and unbatched version of the algorithm. he latency penalty in pullsync is a TCP/IP layer phenomenon governed by the initial handshake and bandwidth delay, not an application-level delivery issue. Both implementations suffer from the exact same latency penalty (TCP Slow Start): When a 200ms delay is injected, both versions see their delivery time increase from ~0.38s to ~1.62s. This ~1.2-second penalty is caused by the initial protocol handshake (the Get -> Offer -> Want) and the TCP congestion window (slow start) ramping up across a high-latency link. Batching application-level data does not improve stream performance: With 200ms of latency, the Batched implementation (1.622s) and the v2.8.1 implementation (1.625s) are essentially identical. This proves that because legacy pullsync already streams data into the socket buffer rather than waiting for individual application-level ACKs, application-level batching provides no benefit. In both cases, the OS segments the stream into identical 1500-byte MTU packets and transmits them at the exact same rate. While providing no speed benefit, the Batched implementation consistently consumes more memory (2.4 MB/op) compared to the legacy v2.8.1 implementation (1.7 MB/op). This is because the batched version must allocate and hold large arrays of chunk data in memory simultaneously before writing them to the socket buffer. In this test, the batching protocol change introduces memory overhead without delivering any network speedup compared to the bee v2.8.1 pullsync implementation. |
|
Measurements on the light testnet is performed by @gacevicljubisa and me. The setup is to have only two nodes in the network running but not connecting to any other nodes, not even to themselves. One node is the uploader node and another node is the syncing node that share the same neighbourhood 0000000 (by using --target-neighborhood cli option on setup). First, a random file of 100MB length is uploaded to the uploader node (which is previously nuked), when it is done uploading, a connect call is performed to connect the uploader node and syncing node in order for pullsyncing to start. It is monitored that no other nodes are connected to the two nodes in the experiment. The connection after the file has been uploaded is ensuring that pushync is not interfering with the pullsync. In total of 6 measurements are performed, 3 with bee v2.8.1 and 3 with the batching pullsyncer, implemented in this branch. Kuberenetes cluster is used for running the bee nodes on light testnet. The testing script, datasets and html interactive chart are attached to this comment. The data show high variance in syncing speeds for either of the syncing algorithm. It also show a pattern when every run eventually drops to about 14.5 chunks per second which is most likely doe to disk I/O ceiling on kubernetes cluster. Before the drop, syncing speeds are significantly higher but with variances for both algorithms in the same ranges. If we look at the pure transfer speed (the first 10,000 chunks) before the IO limits kick in:
Statistically speaking, the variance (the swings between 44 and 156) completely destroys any statistical significance. Both implementations had one run hit ~156 chunks/sec, and both had runs dip below 90. The variance is most likely being driven entirely by the Kubernetes environment (noisy neighbors, CPU allocation, network jitter) rather than the protocol itself. Because every single run eventually drops down to the ~14.5 chunks per second disk IO ceiling, the total completion time of the sync is almost entirely determined by how long the pod was allowed to burst before hitting the ceiling, rather than how fast the chunks were actually traveling over the network. Conclusion: due to the extreme environmental noise in the Kubernetes cluster, the data is inconclusive. There is no statistically significant evidence from these specific runs that batching is better. I would suggest to run the test on the environment with less noise, which wi could prove by having similar results on several runs of the same algorithm. And also to have the environment with local disks. Chart: pullsync-timeseries.html to be opened in web browser for interactivity, but here is a static image as well:
The testing script: pullsync_benchmark.sh Data: CSV files have first column as a timestamp and the third the number of delivered chunks as recorded per metric bee_pullsync_chunks_delivered. Side note: We have also recorded the pullsyncRate as the third column in the csv files, and its value is completely unreliable. We did not investigate why. The value is calculated on the fly internally in bee and did not interfere with the results, but we did not try to address this issue. |

Pullsync Batching Performance Analysis
This PR gives the overview of the performance testing and profiling conducted to evaluate the proposed batched chunk delivery (
DeliveryBatch) against the legacy single-message delivery (v2.8.1) in thepullsyncprotocol. It should not be merged, as it is meant to reproduce the results, but it may be used for potential improvements.The goal is to identify if the batching of delivery messages in the pullsync protocol can result to a speedup in overall deliveries.
To simulate real-world networking overhead accurately, a benchmark was written that spins up real
libp2pnodes locally, multiplexes streams, and transmits chunks between a client and server.This approach gives a convenient testing, benchmarking and profiling, but the pullsync performance can be measured on the real network as well.
There are two benchmarks:
Benchmark
There are to benchmarks, one for he currently released bee v2.8.1 pullsync implementation and one for the proposed batched implementation. Both of them with 25 and 120 chunks delivered. Since the max batch size can be 25 chunks because of the limitation of 128KB payload size of protobuf. There is a possiblity to raise that limit, but with a danger to expose some vulnerability in the network.
Run the libp2p benchmark for 100 iterations tracking memory allocations:
go test -v -run=NONE -bench=BenchmarkDeliveryLibP2P -benchtime=100x -benchmem ./pkg/pullsync/delivery_test.go ./pkg/pullsync/pullsync_test.goProfiling
Ran Go's built-in
pproftools against both theBatchedandv2.8.1branches of the benchmark independently, to identify any bottlenecks and/or improvements.Generate CPU and Memory profiles for the Batched implementation:
Generate CPU and Memory profiles for the legacy v2.8.1 implementation:
Analyze:
CPU Profile
Both profiles reveal an identical bottleneck. In both implementations,
runtime.pthread_cond_wait,runtime.usleep, andruntime.pthread_cond_signalconsume the majority of all execution samples. This indicates the CPU is blocking onlibp2pnetwork I/O synchronization than performing protobuf serialization or algorithmic work.Batched:
v2.8.1 (Single Message):
Memory Profile
Batched:
v2.8.1 (Single Message):
Both the batched and unbatched implementations perform nearly identically over
libp2p(~99.07 msvs~99.08 msforn=25and~475.34 msvs~475.29 msforn=120).The separated CPU profiles reveal why this is the case: the top CPU consumers in both implementations are OS-level thread blocking operations (
runtime.pthread_cond_wait,runtime.usleep,runtime.kevent). These account for over 60-80% of all CPU samples depending on the run. Because the program is almost entirely bottlenecked by waiting forlibp2pstream data to traverse network sockets, any optimizations gained by batching protobuf structures in memory are negligible compared to the network latency.The memory profile shows that chunk parsing and cryptographic hashing (
golang.org/x/crypto/sha3.(*state).Sumandpb.(*Delivery).Unmarshal) dominate memory allocations.Even if the delimitedReaderMaxSize is raised to 1MB, allowing larger batches, no performance gains are visible.
Conclusion
Introducing a breaking protocol change to batch protobuf messages yields no statistical CPU performance gain over the multiplexer latency, so the simpler, single-message streaming approach is preferred.
I encourage everyone interested to validate the findings.
Checklist
Description
Open API Spec Version Changes (if applicable)
Motivation and Context (Optional)
Related Issue (Optional)
Screenshots (if appropriate):
AI Disclosure